/*
 * Unidata Platform
 * Copyright (c) 2013-2020, UNIDATA LLC, All rights reserved.
 *
 * Commercial License
 * This version of Unidata Platform is licensed commercially and is the appropriate option for the vast majority of use cases.
 *
 * Please see the Unidata Licensing page at: https://unidata-platform.com/license/
 * For clarification or additional options, please contact: info@unidata-platform.com
 * -------
 * Disclaimer:
 * -------
 * THIS SOFTWARE IS DISTRIBUTED "AS-IS" WITHOUT ANY WARRANTIES, CONDITIONS AND
 * REPRESENTATIONS WHETHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION THE
 * IMPLIED WARRANTIES AND CONDITIONS OF MERCHANTABILITY, MERCHANTABLE QUALITY,
 * FITNESS FOR A PARTICULAR PURPOSE, DURABILITY, NON-INFRINGEMENT, PERFORMANCE AND
 * THOSE ARISING BY STATUTE OR FROM CUSTOM OR USAGE OF TRADE OR COURSE OF DEALING.
 */
package org.unidata.mdm.rest.data.service;

import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;

import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.PathSegment;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.StreamingOutput;

import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.cxf.jaxrs.ext.multipart.Attachment;
import org.apache.cxf.jaxrs.ext.multipart.Multipart;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.unidata.mdm.core.context.DeleteLargeObjectContext;
import org.unidata.mdm.core.context.FetchLargeObjectContext;
import org.unidata.mdm.core.context.UpsertLargeObjectContext;
import org.unidata.mdm.core.dto.LargeObjectResult;
import org.unidata.mdm.core.service.LargeObjectsService;
import org.unidata.mdm.core.service.MetaModelService;
import org.unidata.mdm.core.type.data.OperationType;
import org.unidata.mdm.core.type.lob.LargeObjectAcceptance;
import org.unidata.mdm.core.type.model.EntityElement;
import org.unidata.mdm.core.type.timeline.Timeline;
import org.unidata.mdm.core.util.FileUtils;
import org.unidata.mdm.core.util.LargeObjectUtils;
import org.unidata.mdm.data.context.DeleteRequestContext;
import org.unidata.mdm.data.context.DeleteRequestContext.DeleteRequestContextBuilder;
import org.unidata.mdm.data.context.GetRecordTimelineRequestContext;
import org.unidata.mdm.data.context.GetRelationsRequestContext;
import org.unidata.mdm.data.context.GetRequestContext;
import org.unidata.mdm.data.context.GetRequestContext.GetRequestContextBuilder;
import org.unidata.mdm.data.context.SplitRecordRequestContext;
import org.unidata.mdm.data.context.UpsertRequestContext;
import org.unidata.mdm.data.context.UpsertRequestContext.UpsertRequestContextBuilder;
import org.unidata.mdm.data.dto.DeleteRecordDTO;
import org.unidata.mdm.data.dto.GetRecordDTO;
import org.unidata.mdm.data.dto.SplitRecordsDTO;
import org.unidata.mdm.data.dto.UpsertRecordDTO;
import org.unidata.mdm.data.exception.DataProcessingException;
import org.unidata.mdm.data.service.DataRecordsService;
import org.unidata.mdm.data.service.segments.records.delete.RecordDeleteAccessExecutor;
import org.unidata.mdm.data.service.segments.records.delete.RecordDeleteDataConsistencyExecutor;
import org.unidata.mdm.data.service.segments.records.delete.RecordDeleteFinishExecutor;
import org.unidata.mdm.data.service.segments.records.delete.RecordDeleteIndexingExecutor;
import org.unidata.mdm.data.service.segments.records.delete.RecordDeletePeriodCheckExecutor;
import org.unidata.mdm.data.service.segments.records.delete.RecordDeletePersistenceExecutor;
import org.unidata.mdm.data.service.segments.records.delete.RecordDeleteStartExecutor;
import org.unidata.mdm.data.type.data.EtalonRecord;
import org.unidata.mdm.data.type.data.OriginRecord;
import org.unidata.mdm.data.type.keys.RecordKeys;
import org.unidata.mdm.meta.configuration.Descriptors;
import org.unidata.mdm.rest.core.converter.RoleRoConverter;
import org.unidata.mdm.rest.data.converter.DataRecordEtalonConverter;
import org.unidata.mdm.rest.data.converter.DataRecordOriginConverter;
import org.unidata.mdm.rest.data.converter.ErrorInfoToRestErrorInfoConverter;
import org.unidata.mdm.rest.data.converter.LargeObjectToRestLargeObjectConverter;
import org.unidata.mdm.rest.data.converter.RecordDiffStateConverter;
import org.unidata.mdm.rest.data.converter.RecordKeysConverter;
import org.unidata.mdm.rest.data.converter.TimelineToTimelineROConverter;
import org.unidata.mdm.rest.data.exception.DataRestExceptionIds;
import org.unidata.mdm.rest.data.ro.EtalonRecordRO;
import org.unidata.mdm.rest.data.ro.ExtendedSimpleAttributeRO;
import org.unidata.mdm.rest.data.ro.FilterByCriteriaRequestRO;
import org.unidata.mdm.rest.data.ro.FullRecordRO;
import org.unidata.mdm.rest.data.ro.OriginRecordRO;
import org.unidata.mdm.rest.data.ro.OriginRecordWrapperRO;
import org.unidata.mdm.rest.data.ro.TimelineRO;
import org.unidata.mdm.rest.data.type.rendering.DataRestInputRenderingAction;
import org.unidata.mdm.rest.data.type.rendering.DataRestOutputRenderingAction;
import org.unidata.mdm.rest.data.util.RestUtils;
import org.unidata.mdm.rest.system.ro.ErrorInfo;
import org.unidata.mdm.rest.system.ro.ErrorResponse;
import org.unidata.mdm.rest.system.ro.RestResponse;
import org.unidata.mdm.rest.system.ro.UpdateResponse;
import org.unidata.mdm.rest.system.service.AbstractRestService;
import org.unidata.mdm.rest.system.util.RestConstants;
import org.unidata.mdm.system.exception.PlatformRuntimeException;
import org.unidata.mdm.system.service.ExecutionService;
import org.unidata.mdm.system.service.PipelineService;
import org.unidata.mdm.system.service.RenderingService;
import org.unidata.mdm.system.type.pipeline.Pipeline;
import org.unidata.mdm.system.type.rendering.MapInputSource;
import org.unidata.mdm.system.type.runtime.MeasurementContextName;
import org.unidata.mdm.system.type.runtime.MeasurementPoint;
import org.unidata.mdm.system.util.ConvertUtils;
import org.unidata.mdm.system.util.IdUtils;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
/**
 * @author Michael Yashin. Created on 19.05.2015.
 */
@Path("entities")
@Consumes({MediaType.APPLICATION_JSON})
@Produces({MediaType.APPLICATION_JSON})
public class DataEntityRestService extends AbstractRestService {

    private static final String START = "start";
    private static final String LIMIT = "limit";

    /**
     * Default value for delete cascade.
     */
    public static final boolean DEFAULT_DELETE_CASCADE_VALUE = true;
    /**
     * Logger.
     */
    private static final Logger LOGGER = LoggerFactory.getLogger(DataEntityRestService.class);

    /**
     * Data records service.
     */
    @Autowired
    private DataRecordsService dataRecordsService;
    /**
     * Meta model service.
     */
    @Autowired
    private MetaModelService metaModelService;

    @Autowired
    private PipelineService pipelineService;

    @Autowired
    private ExecutionService executionService;

    @Autowired
    private RenderingService renderingService;

    @Autowired
    private LargeObjectsService largeObjectsService;

    // TODO @Modules
//    /**
//     * Validation service.
//     */
//    @Autowired
//    private ValidationServiceExt validationService;

    // TODO @Modules
//    /**
//     * Workflow component.
//     */
//    @Autowired(required = false)
//    private WorkflowServiceExt workflowService;
//
//    @Autowired
//    private ClassifierMetaService classifierMetaService;
//    @Autowired
//    private ClassifierRightService classifierRightService;

    /**
     * Gets a data record by ID.
     *
     * @param id the id
     * @return a data record
     */
    @GET
    @Path("{" + RestConstants.DATA_PARAM_ID + "}{p:/?}{"
            + RestConstants.DATA_PARAM_DATE + ": " + RestConstants.DEFAULT_TIMESTAMP_PATTERN + "}")
    @Operation(
        description = "Получить запись по ID",
        method = "GET",
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = RestResponse.class)), responseCode = "200"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = ErrorResponse.class)), responseCode = "500")
        }
    )
    public Response getById(
            @Parameter(in = ParameterIn.PATH, description = "ID сущности") @PathParam(RestConstants.DATA_PARAM_ID) String id,
            @Parameter(in = ParameterIn.PATH, description = "Дата на таймлайне") @PathParam(RestConstants.DATA_PARAM_DATE) String dateAsString,
            @Parameter(in = ParameterIn.QUERY, description = "Включать неактивные версии в эталон или нет (true/false)") @QueryParam(RestConstants.DATA_PARAM_INCLUDE_INACTIVE) String includeInactiveAsString,
            @Parameter(in = ParameterIn.QUERY, description = "Включать неподтвержденные версии в эталон или нет (true/false)") @QueryParam(RestConstants.DATA_PARAM_INCLUDE_DRAFTS) String includeDraftsAsString,
            @Parameter(in = ParameterIn.QUERY, description = "Включить версии совместные с указанным operationId") @QueryParam(RestConstants.DATA_PARAM_OPERATION_ID) String operationId,
            @Parameter(in = ParameterIn.QUERY, description = "Вернуть разницу между драфтом и эталоном") @QueryParam(RestConstants.DATA_PARAM_DIFF_TO_DRAFT) String diffToDraftAsString,
            @Parameter(in = ParameterIn.QUERY, description = "Вернуть разницу между эталоном и предыдущим состоянием (одну версию назад)") @QueryParam(RestConstants.DATA_PARAM_DIFF_TO_PREVIOUS) String diffToPreviousAsString) {

        MeasurementPoint.init(MeasurementContextName.MEASURE_UI_GET);
        MeasurementPoint.start();
        try {

            Date asOf = ConvertUtils.string2Date(dateAsString);
            boolean includeInactive = BooleanUtils.toBoolean(includeInactiveAsString);
            boolean includeDrafts = BooleanUtils.toBoolean(includeDraftsAsString);
            boolean diffToDraft = BooleanUtils.toBoolean(diffToDraftAsString);
            boolean diffToPrevious = BooleanUtils.toBoolean(diffToPreviousAsString);

            GetRequestContextBuilder ctxb = GetRequestContext.builder()
                    .etalonKey(id)
                    .forDate(asOf)
                    .forOperationId(operationId)
                    .includeInactive(includeInactive)
                    .includeDrafts(includeDrafts)
                    .diffToDraft(diffToDraft)
                    .diffToPrevious(diffToPrevious)
                    .fragment(() ->
                        GetRelationsRequestContext.builder()
                            .applyToAll(true)
                            .build());

            MapInputSource mis = new MapInputSource();
            mis.putString("id", id);
            mis.putString("operationId", operationId);
            mis.putDate("asOf", asOf);
            mis.putBoolean("includeInactive", includeInactive);
            mis.putBoolean("includeDrafts", includeDrafts);
            mis.putBoolean("diffToDraft", diffToDraft);
            mis.putBoolean("diffToPrevious", diffToPrevious);

            renderingService.renderInput(DataRestInputRenderingAction.ATOMIC_GET_INPUT, ctxb, mis);

            // Pipeline test
            GetRecordDTO result = dataRecordsService.getRecord(ctxb.build());

            // TODO @Modules
//            if (MapUtils.isNotEmpty(result.getClassifiers())) {
//                boolean userHasRightToRead = isUserHasRightToRead(result);
//
//                if (!userHasRightToRead) {
//                    result.setClassifiers(new HashMap<>());
//                }
//
//                result.getClassifiers().forEach((k, list) ->
//                        result.getDqErrors().addAll(list.stream().flatMap(c -> c.getDqErrors().stream()).collect(Collectors.toList()))
//                );
//
//            }
//            if (CollectionUtils.isEmpty(result.getTasks()) && result.getRecordKeys() != null && workflowService != null) {
//                GetTasksRequestContext tCtx = new GetTasksRequestContext.GetTasksRequestContextBuilder()
//                        .candidateGroups(workflowService.currentUserCandidateGroups())
//                        .processKey(result.getRecordKeys().getEtalonKey().getId())
//                        .build();
//
//                List<WorkflowTaskDTO> tasks = new ArrayList<>();
//                workflowService.tasks(tCtx).forEach(task -> {
//                    if (!task.isFinished() && task.getTaskAssignee() == null) {
//                        tasks.add(task);
//                    }
//                });
//
//                result.setTasks(tasks);
//            }
            EtalonRecordRO record = generateEtalonRecordRO(result);
            return ok(new RestResponse<>(record));
        } finally {
            MeasurementPoint.stop();
        }
    }

    /**
     * Gets a data record by ID.
     *
     * @param id the id
     * @return a data record
     */
    @GET
    @Path("{" + RestConstants.DATA_PARAM_ID + "}/{" + RestConstants.DATA_PARAM_DATE + "}/{" + RestConstants.DATA_PARAM_LUD + "}")
    @Operation(
        description = "Получить запись по ID",
        method = "GET",
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = RestResponse.class)), responseCode = "200"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = ErrorResponse.class)), responseCode = "500")
        }
    )
    public Response getById(
            @Parameter(in = ParameterIn.PATH, description = "ID сущности") @PathParam(RestConstants.DATA_PARAM_ID) String id,
            @Parameter(in = ParameterIn.PATH, description = "Дата на таймлайне") @PathParam(RestConstants.DATA_PARAM_DATE) String dateAsString,
            @Parameter(in = ParameterIn.PATH, description = "Дата последнего обновления (вид на дату)") @PathParam(RestConstants.DATA_PARAM_LUD) String
                    ludAsString,
            @Parameter(in = ParameterIn.QUERY, description = "Включать неактивные версии в эталон или нет (true/false)") @QueryParam(RestConstants.DATA_PARAM_INCLUDE_INACTIVE) String
                    includeInactiveAsString,
            @Parameter(in = ParameterIn.QUERY, description = "Включать неподтвержденные версии в эталон или нет (true/false)") @QueryParam(RestConstants.DATA_PARAM_INCLUDE_DRAFTS) String
                    includeDraftsAsString,
            @Parameter(in = ParameterIn.QUERY, description = "Включить версии совместные с указанным operationId") @QueryParam(RestConstants.DATA_PARAM_OPERATION_ID) String
                    operationId,
            @Parameter(in = ParameterIn.QUERY, description = "Вернуть разницу между драфтом и эталоном") @QueryParam(RestConstants.DATA_PARAM_DIFF_TO_DRAFT) String
                    diffToDraftAsString,
            @Parameter(in = ParameterIn.QUERY, description = "Вернуть разницу между эталоном и предыдущим состоянием (одну версию назад)") @QueryParam(RestConstants.DATA_PARAM_DIFF_TO_PREVIOUS) String
                    diffToPreviousAsString) {

        MeasurementPoint.init(MeasurementContextName.MEASURE_UI_GET);
        MeasurementPoint.start();
        try {

            Date asOf = ConvertUtils.string2Date(dateAsString);
            Date lastUpdate = ConvertUtils.string2Date(ludAsString);
            boolean includeInactive = BooleanUtils.toBoolean(includeInactiveAsString);
            boolean includeDrafts = BooleanUtils.toBoolean(includeDraftsAsString);
            boolean diffToDraft = BooleanUtils.toBoolean(diffToDraftAsString);
            boolean diffToPrevious = BooleanUtils.toBoolean(diffToPreviousAsString);

            GetRequestContextBuilder ctxb = GetRequestContext.builder()
                    .etalonKey(id)
                    .forDate(asOf)
                    .forLastUpdate(lastUpdate)
                    .forOperationId(operationId)
                    .includeInactive(includeInactive)
                    .includeDrafts(includeDrafts)
                    .diffToDraft(diffToDraft)
                    .diffToPrevious(diffToPrevious)
                    .fragment(() ->
                        GetRelationsRequestContext.builder()
                            .applyToAll(true)
                            .build());

            MapInputSource mis = new MapInputSource();
            mis.putString("id", id);
            mis.putString("operationId", operationId);
            mis.putDate("asOf", asOf);
            mis.putDate("lastUpdate", lastUpdate);
            mis.putBoolean("includeInactive", includeInactive);
            mis.putBoolean("includeDrafts", includeDrafts);
            mis.putBoolean("diffToDraft", diffToDraft);
            mis.putBoolean("diffToPrevious", diffToPrevious);

            renderingService.renderInput(DataRestInputRenderingAction.ATOMIC_GET_INPUT, ctxb, mis);

            GetRecordDTO result = dataRecordsService.getRecord(ctxb.build());

            EtalonRecordRO record = generateEtalonRecordRO(result);

            return ok(new RestResponse<>(record));
        } finally {
            MeasurementPoint.stop();
        }
    }

    private EtalonRecordRO generateEtalonRecordRO(GetRecordDTO result) {

        if (result.getEtalon() == null) {
            return null;
        }

        EtalonRecord etalonRecord = result.getEtalon();
        final EtalonRecordRO record = DataRecordEtalonConverter.to(etalonRecord, Collections.emptyList());

        renderingService.renderOutput(DataRestOutputRenderingAction.ATOMIC_GET_OUTPUT, result, record);

// TODO @Modules
//        record.setWorkflowState(WorkflowTaskConverter.to(result.getTasks()));

        record.setRights(RoleRoConverter.convertResourceSpecificRights(result.getRights()));
        record.setDiffToDraft(RecordDiffStateConverter.to(result.getDiffToDraft()));
        record.setEntityType(metaModelService.instance(Descriptors.DATA).isRegister(record.getEntityName())
                ? RestConstants.REGISTER_ENTITY_TYPE
                : RestConstants.LOOKUP_ENTITY_TYPE);

        return record;
    }

    private boolean isUserHasRightToRead(GetRecordDTO result) {
        boolean userHasRightToRead = true;


        // TODO @Modules
//        Set<Entry<String, List<GetClassifierDTO>>> classifiers = result.getClassifiers().entrySet();
//
//
//        for (Entry<String, List<GetClassifierDTO>> entry : classifiers) {
//            String classifierName = entry.getKey();
//            List<GetClassifierDTO> data = entry.getValue();
//
//            Try<Map<String, List<ClassifierNode>>> nodes =
//                    classifierMetaService.pathToNodes(classifierName,
//                            data.stream()
//                                    .map(a -> a.getClassifierKeys().getOriginKey().getNodeId()).collect(Collectors.toList()));
//
//            for (Map<String, List<ClassifierNode>> nodeId : nodes) {
//
//                for (Entry<String, List<ClassifierNode>> entry2 : nodeId.entrySet()) {
//
//                    ClassifierRight nodeRight = classifierRightService.getNodeRight(classifierName, entry2.getKey(), entry2.getValue());
//                    if (!nodeRight.isRead()) {
//                        userHasRightToRead = false;
//                    }
//                }
//            }
//        }
        return userHasRightToRead;
    }

    /**
     * Gets a data record by ID.
     *
     * @param id the id
     * @return a data record
     */
    @GET
    @Path("/record-as-xml/{" + RestConstants.DATA_PARAM_ID + "}/{" + RestConstants.DATA_PARAM_DATE + "}")
    @Operation(
        description = "Получить запись по ID как строку в формате XML",
        method = "GET",
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = String.class)), responseCode = "200"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = ErrorResponse.class)), responseCode = "500")
        }
    )
    public Response getByIdAsXMLString(
            @Parameter(in = ParameterIn.PATH, description = "ID сущности") @PathParam(RestConstants.DATA_PARAM_ID) String id,
            @Parameter(in = ParameterIn.PATH, description = "Дата на таймлайне") @PathParam(RestConstants.DATA_PARAM_DATE) String dateAsString,
            @Parameter(in = ParameterIn.QUERY, description = "Включать неактивные версии в эталон или нет (true/false)") @QueryParam(RestConstants.DATA_PARAM_INCLUDE_INACTIVE) String
                    includeInactiveAsString,
            @Parameter(in = ParameterIn.QUERY, description = "Включать неподтвержденные версии в эталон или нет (true/false)") @QueryParam(RestConstants.DATA_PARAM_INCLUDE_DRAFTS) String
                    includeDraftsAsString,
            @Parameter(in = ParameterIn.QUERY, description = "Включить версии совместные с указанным operationId") @QueryParam(RestConstants.DATA_PARAM_OPERATION_ID) String
                    operationId) {
        Date asOf = ConvertUtils.string2Date(dateAsString);
        GetRequestContext ctx = GetRequestContext.builder()
                .etalonKey(id)
                .forDate(asOf)
                .forOperationId(operationId)
//                .fetchRelations(true)
                .includeInactive(BooleanUtils.toBoolean(includeInactiveAsString))
                .includeDrafts(BooleanUtils.toBoolean(includeDraftsAsString))
                .diffToDraft(false)
                .diffToPrevious(false)
//                .fetchClusters(false)
//                .tasks(false)
                .build();
        String result = dataRecordsService.getRecordAsXMLString(ctx);
        return ok(new RestResponse<>(result));

    }

    /**
     * Create a record.
     *
     * @param atomicUpsert the full record to save
     * @return created record
     */
    @POST
    @Path("/atomic")
    @Operation(
        description = "Создать запись",
        method = "POST",
        requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = FullRecordRO.class)), description = "Запись"),
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = RestResponse.class)), responseCode = "200"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = ErrorResponse.class)), responseCode = "500")
        }
    )
    public Response atomicCreate(FullRecordRO atomicUpsert) {

        MeasurementPoint.init(MeasurementContextName.MEASURE_UI_CREATE);
        MeasurementPoint.start();
        try {
            return ok(atomicUpsertRecord(atomicUpsert));
        } finally {
            MeasurementPoint.stop();
        }
    }

    /**
     * Update golden record.
     *
     * @param id the record id
     * @param atomicUpsert the full record to save
     * @return updated record
     */
    @PUT
    @Path("/atomic/{" + RestConstants.DATA_PARAM_ID + "}")
    @Operation(
        description = "Обновить запись. На обновление присылается полная запись",
        method = "PUT",
        requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = FullRecordRO.class)), description = "Запись"),
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = RestResponse.class)), responseCode = "200"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = ErrorResponse.class)), responseCode = "500")
        }
    )
    public Response atomicUpdate(
            @Parameter(description = "ID сущности", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_ID) String id,
            FullRecordRO atomicUpsert) {

        MeasurementPoint.init(MeasurementContextName.MEASURE_UI_UPDATE);
        MeasurementPoint.start();
        try {
            return ok(atomicUpsertRecord(atomicUpsert));
        } finally {
            MeasurementPoint.stop();
        }
    }

    public RestResponse<EtalonRecordRO> atomicUpsertRecord(FullRecordRO fullRecordRO) {

        EtalonRecordRO record = fullRecordRO.getDataRecord();

        String externalId = null;
        if (Objects.isNull(record.getEtalonId())) {

            EntityElement entity = metaModelService.instance(Descriptors.DATA).getElement(record.getEntityName());
            if (entity != null && !entity.isGenerating()) {
                externalId = IdUtils.v1String();
                record.setExternalId(externalId);
            }
        }

        String adminSourceSystem = metaModelService.instance(Descriptors.SOURCE_SYSTEMS).getAdminElement().getName();

        OperationType operationType = StringUtils.isNoneBlank(record.getOperationType())
                ? OperationType.valueOf(record.getOperationType())
                : null;

        UpsertRequestContextBuilder ctxBuilder = UpsertRequestContext.builder()
                .record(DataRecordEtalonConverter.from(record))
                .etalonKey(record.getEtalonId())
                .externalId(externalId)
                .sourceSystem(record.getEtalonId() == null ? adminSourceSystem : null)
                .entityName(record.getEtalonId() == null ? record.getEntityName() : null)
                .validFrom(ConvertUtils.localDateTime2Date(record.getValidFrom()))
                .validTo(ConvertUtils.localDateTime2Date(record.getValidTo()))
                .suppressWorkflow(operationType == OperationType.COPY);

        renderingService.renderInput(DataRestInputRenderingAction.ATOMIC_UPSERT_INPUT, ctxBuilder, fullRecordRO);

        UpsertRequestContext ctx = ctxBuilder.build();

        if (operationType == OperationType.COPY) {
            ctx.operationType(operationType);
        }

        Boolean success = Boolean.TRUE;
        EtalonRecordRO retval = null;
        List<ErrorInfo> messages = new ArrayList<>();

        try {

            UpsertRecordDTO result = dataRecordsService.upsertRecord(ctx);

            retval = DataRecordEtalonConverter.to(result.getEtalon(), result.getDuplicateIds());

            // UN-8547 Full result is not read by the UI.
            if (Objects.isNull(retval)) {
                retval = new EtalonRecordRO();
                retval.setEtalonId(result.getRecordKeys().getEtalonKey().getId());
                retval.setEntityName(result.getRecordKeys().getEntityName());
            } else {
                renderingService.renderOutput(DataRestOutputRenderingAction.ATOMIC_UPSERT_OUTPUT, result, fullRecordRO);
            }

            buildErrors(messages, result);

        } catch (PlatformRuntimeException exc) {

            retval = record;
            success = Boolean.FALSE;
            if (!renderingService.renderOutput(DataRestOutputRenderingAction.ATOMIC_UPSERT_OUTPUT, exc, retval)) {
                throw exc;
            }
            // TODO @Modules
//            if (exc.getId().equals(DataRestExceptionIds.EX_DATA_ORIGIN_UPSERT_NEW_DQ_FAILED_BEFORE) || exc.getId().equals(DataRestExceptionIds.EX_DATA_CLASSIFIER_UPSERT_NEW_DQ_FAILED_BEFORE)) {
//                retval = record;
//                success = Boolean.FALSE;
//
//                if (CollectionUtils.isNotEmpty(ctx.getDqErrors())) {
//                    DataRecordEtalonConverter.copyDQErrors(ctx.getDqErrors(), retval.getDqErrors());
//                }
//            } else {
//                throw exc;
//            }

        }

        retval.setEntityType(metaModelService.instance(Descriptors.DATA).isRegister(retval.getEntityName())
                ? RestConstants.REGISTER_ENTITY_TYPE
                : RestConstants.LOOKUP_ENTITY_TYPE);

        RestResponse<EtalonRecordRO> response = new RestResponse<>(retval);
        response.setErrors(messages);
        response.setSuccess(success);
        return response;
    }

    private void buildErrors(List<ErrorInfo> messages, UpsertRecordDTO result) {
        if (CollectionUtils.isNotEmpty(result.getErrors())) {
            messages.addAll(result.getErrors().stream()
                    .map(ErrorInfoToRestErrorInfoConverter::convert)
                    .collect(Collectors.toList()));
        }
//        if (MapUtils.isNotEmpty(result.getRelations())) {
//            result.getRelations().values().stream()
//                    .flatMap(Collection::stream)
//                    .forEach(upsertRelationDTO -> {
//                        if (CollectionUtils.isNotEmpty(upsertRelationDTO.getErrors())) {
//                            messages.addAll(upsertRelationDTO.getErrors().stream()
//                                    .map(ErrorInfoToRestErrorInfoConverter::convert)
//                                    .collect(Collectors.toList()));
//                        }
//                    });
//        }
//        if (MapUtils.isNotEmpty(result.getDeleteRelations())) {
//            result.getDeleteRelations().values().stream()
//                    .flatMap(Collection::stream)
//                    .forEach(deleteRelationDTO -> {
//                        if (CollectionUtils.isNotEmpty(deleteRelationDTO.getErrors())) {
//                            messages.addAll(deleteRelationDTO.getErrors().stream()
//                                    .map(ErrorInfoToRestErrorInfoConverter::convert)
//                                    .collect(Collectors.toList()));
//                        }
//                    });
//        }
    }

    /**
     * Deletes a etalon record.
     *
     * @param id etalon record id
     * @return id of the record deleted
     */
    @DELETE
    @Path("{" + RestConstants.DATA_PARAM_ID + "}")
    @Operation(
        description = "Удалить запись",
        method = "DELETE",
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = UpdateResponse.class)), responseCode = "200"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = ErrorResponse.class)), responseCode = "500")
        }
    )
    public Response delete(
            @Parameter(description = "ID сущности", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_ID) String id,
            @Parameter(description = "Удалять физически запись или логически", in = ParameterIn.QUERY) @QueryParam(RestConstants.QUERY_PARAM_WIPE) String wipeAsString) {

        MeasurementPoint.init(MeasurementContextName.MEASURE_UI_DELETE_BY_ETALON);
        MeasurementPoint.start();
        try {

            boolean wipe = BooleanUtils.toBoolean(wipeAsString);
            DeleteRequestContextBuilder ctxb = DeleteRequestContext.builder()
                    .etalonKey(id)
                    .inactivateEtalon(true)
                    .wipe(wipe)
                    .cascade(DEFAULT_DELETE_CASCADE_VALUE);

            MapInputSource mis = new MapInputSource();
            mis.putString("id", id);
            mis.putBoolean("wipe", wipe);
            mis.putBoolean("inactivateEtalon", true);
            mis.putBoolean("inactivateOrigin", false);
            mis.putBoolean("inactivatePeriod", false);

            renderingService.renderInput(DataRestInputRenderingAction.ATOMIC_DELETE_INPUT, ctxb, mis);

            DeleteRecordDTO deleteResult = dataRecordsService.deleteRecord(ctxb.build());

            renderingService.renderOutput(DataRestOutputRenderingAction.ATOMIC_DELETE_OUTPUT, deleteResult, null);

            String deletedKey = getDeletedKey(deleteResult);
            if (StringUtils.isBlank(deletedKey)) {
                final String message = "Etalon delete failed (no key received) for ID [{}]!";
                LOGGER.warn(message, id);
                throw new DataProcessingException(message, DataRestExceptionIds.EX_DATA_ETALON_DELETE_FAILED, id);
            }

            UpdateResponse response = new UpdateResponse(true, id);
            if (CollectionUtils.isNotEmpty(deleteResult.getErrors())) {
                response.setErrors(deleteResult.getErrors().stream()
                        .map(ErrorInfoToRestErrorInfoConverter::convert)
                        .collect(Collectors.toList()));
            }
            return Response.ok(response).build();
        } finally {
            MeasurementPoint.stop();
        }
    }

    /**
     * Deletes a etalon record.
     *
     * @param id etalon record id
     * @return id of the record deleted
     */
    @Deprecated
    @DELETE
    @Path("/" + RestConstants.PATH_PARAM_WIPE + "/{" + RestConstants.DATA_PARAM_ID + "}")
    @Operation(
        description = "Удалить запись",
        method = "DELETE",
        deprecated = true,
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = UpdateResponse.class)), responseCode = "200"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = ErrorResponse.class)), responseCode = "500")
        }
    )
    public Response wipe(@Parameter(description = "ID сущности", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_ID) String id) {

        MeasurementPoint.init(MeasurementContextName.MEASURE_UI_DELETE_BY_ETALON);
        MeasurementPoint.start();
        try {

            DeleteRequestContext ctx = DeleteRequestContext.builder()
                    .etalonKey(id)
                    .wipe(true)
                    .cascade(DEFAULT_DELETE_CASCADE_VALUE)
                    .build();

            DeleteRecordDTO deleteResult = dataRecordsService.deleteRecord(ctx);
            String deletedKey = getDeletedKey(deleteResult);
            if (StringUtils.isBlank(deletedKey)) {
                final String message = "Etalon wipe failed (no key received) for ID [{}]!";
                LOGGER.warn(message, id);
                throw new DataProcessingException(message, DataRestExceptionIds.EX_DATA_ETALON_WIPE_FAILED, id);
            }

            UpdateResponse response = new UpdateResponse(true, id);
            if (CollectionUtils.isNotEmpty(deleteResult.getErrors())) {
                response.setErrors(deleteResult.getErrors().stream()
                        .map(ErrorInfoToRestErrorInfoConverter::convert)
                        .collect(Collectors.toList()));
            }
            return Response.ok(response).build();
        } finally {
            MeasurementPoint.stop();
        }
    }

    /**
     * Gets a data record by ID.
     *
     * @param etalonId the id
     * @return a data record
     */
    @GET
    @Path("/" + RestConstants.PATH_PARAM_ORIGIN + "/{" + RestConstants.DATA_PARAM_ID + "}" + "{p:/?}{" + RestConstants.DATA_PARAM_DATE + ": " + RestConstants.DEFAULT_TIMESTAMP_PATTERN + "}")
    @Operation(
        description = "Получить ориджин записи по ID эталона",
        method = "GET",
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = RestResponse.class)), responseCode = "200"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = ErrorResponse.class)), responseCode = "500")
        }
    )
    public Response getOriginsByEtalonId(
            @Parameter(description = "ID сущности", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_ID) String etalonId,
            @Parameter(description = "Дата", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_DATE) String dateAsString,
            @Parameter(description = "Включать неактивные версии в эталон или нет (true/false)", in = ParameterIn.QUERY) @QueryParam(RestConstants.DATA_PARAM_INCLUDE_INACTIVE)
                String includeInactiveAsString,
            @Parameter(description = "Включать неподтвержденные версии в эталон или нет (true/false)", in = ParameterIn.QUERY) @QueryParam(RestConstants.DATA_PARAM_INCLUDE_DRAFTS)
                String includeDraftsAsString) {

        Date asOf = ConvertUtils.string2Date(dateAsString);

        GetRequestContext ctx = GetRequestContext.builder()
                .etalonKey(etalonId)
                .fetchOrigins(true)
                .includeMerged(true)
                .forDate(asOf)
//                .fetchRelations(false)
//                .fetchClassifiers(true)
                .includeInactive(BooleanUtils.toBoolean(includeInactiveAsString))
                .includeDrafts(BooleanUtils.toBoolean(includeDraftsAsString))
                .includeWinners(true)
//                .tasks(true)
                .build();

        GetRecordDTO result = dataRecordsService.getRecord(ctx);
        if (result == null || result.getEtalon() == null) {
            final String message = "Etalon record not found for ID [{}].";
            LOGGER.warn(message, etalonId);
            throw new DataProcessingException(message,
                    DataRestExceptionIds.EX_DATA_ETALON_NOT_FOUND, etalonId);
        }

        List<OriginRecordWrapperRO> origins = new ArrayList<>();
        for (OriginRecord o : result.getOrigins()) {

            final OriginRecordRO origin = DataRecordOriginConverter.to(o, result.getEtalon());
            // TODO @Modules
//            for (Entry<String, List<GetClassifierDTO>> entry : result.getClassifiers().entrySet()) {
//
//                if (CollectionUtils.isEmpty(entry.getValue())) {
//                    continue;
//                }
//
//                for (GetClassifierDTO cdto : entry.getValue()) {
//
//                    if (CollectionUtils.isEmpty(cdto.getOrigins())) {
//                        continue;
//                    }
//
//                    OriginClassifier ocl = cdto.getOrigins().stream()
//                            .filter(vocl ->
//                                    Objects.nonNull(vocl.getInfoSection().getClassifierOriginKey().getRecord().getId())
//                                            && Objects.nonNull(vocl.getInfoSection().getClassifierOriginKey().getRecord().getId())
//                                            && vocl.getInfoSection().getClassifierOriginKey().getRecord().getId().equals(o.getInfoSection().getOriginKey().getId()))
//                            .findFirst()
//                            .orElse(null);
//
//                    if (Objects.nonNull(ocl)) {
//                        origin.getClassifiers().add(ClassifierRecordConverter.to(ocl));
//                        cdto.getOrigins().remove(ocl);
//                    }
//                }
//            }
            OriginRecordWrapperRO orw = new OriginRecordWrapperRO();
            orw.setOriginRecord(origin);
            origin.getSimpleAttributes().stream().forEach(s -> {
                if ((s instanceof ExtendedSimpleAttributeRO) && ((ExtendedSimpleAttributeRO) s).isWinner()) {
                    orw.getWinnerPaths().add(s.getName());
                }
            });
            origins.add(orw);
        }

        // UN-6431 Check for orphaned classifier record without origins data versions
        Map<String, OriginRecordRO> missing = new HashMap<>();
        // TODO @Modules
//        result.getClassifiers().values().stream()
//                .flatMap(Collection::stream)
//                .map(GetClassifierDTO::getOrigins)
//                .flatMap(Collection::stream)
//                .forEach(orphan -> {
//
//                    OriginRecordRO oro = missing.get(orphan.getInfoSection().getClassifierOriginKey().getRecord().getId());
//                    if (Objects.isNull(oro)) {
//
//                        oro = new OriginRecordRO();
//                        oro.setOriginId(orphan.getInfoSection().getClassifierOriginKey().getRecord().getId()); // System ID
//                        oro.setExternalId(orphan.getInfoSection().getClassifierOriginKey().getRecord().getExternalId()); // Foreign ID
//                        oro.setSourceSystem(orphan.getInfoSection().getClassifierOriginKey().getRecord().getSourceSystem()); // Source system
//                        oro.setEntityName(orphan.getInfoSection().getClassifierOriginKey().getRecord().getEntityName()); // Entity name
//                        oro.setGsn("0");
//
//                        missing.put(oro.getOriginId(), oro);
//                    }
//
//                    oro.getClassifiers().add(ClassifierRecordConverter.to(orphan));
//                });
        if (missing.values() != null) {
            for (OriginRecordRO or : missing.values()) {
                OriginRecordWrapperRO orw = new OriginRecordWrapperRO();
                orw.setOriginRecord(or);
                origins.add(orw);
            }
        }

        return ok(new RestResponse<>(origins));
    }

    /**
     * Gets a data record by ID.
     *
     * @param etalonId the id
     * @return a data record
     */
    @GET
    @Path("/paged/" + RestConstants.PATH_PARAM_ORIGIN + "/{" + RestConstants.DATA_PARAM_ID + "}" + "{p:/?}{"
            + RestConstants.DATA_PARAM_DATE + ": " + RestConstants.DEFAULT_TIMESTAMP_PATTERN + "}")
    @Operation(
        description = "Получить ориджин записи по ID эталона",
        method = "GET",
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = RestResponse.class)), responseCode = "200"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = ErrorResponse.class)), responseCode = "500")
        }
    )
    public Response getPagedOriginsByEtalonId(
            @Parameter(description = "ID сущности", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_ID) String etalonId,
            @Parameter(description = "Дата", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_DATE) String dateAsString,
            @Parameter(description = "Включать неактивные версии в эталон или нет (true/false)", in = ParameterIn.QUERY)
                @QueryParam(RestConstants.DATA_PARAM_INCLUDE_INACTIVE) String includeInactiveAsString,
            @Parameter(description = "Включать неподтвержденные версии в эталон или нет (true/false)", in = ParameterIn.QUERY)
                @QueryParam(RestConstants.DATA_PARAM_INCLUDE_DRAFTS) String includeDraftsAsString,
            @Parameter(description =  "С ", in = ParameterIn.QUERY) @QueryParam(START) Integer start,
            @Parameter(description = "Количество", in = ParameterIn.QUERY) @QueryParam(LIMIT) Integer limit) {

        // Find all origins.
        Response response = getOriginsByEtalonId(etalonId, dateAsString, includeInactiveAsString, includeDraftsAsString);
        RestResponse restResponse = (RestResponse) response.getEntity();

        List<OriginRecordRO> origins = (List<OriginRecordRO>) restResponse.getContent();
        List<OriginRecordRO> pageContent = Collections.emptyList();

        if (start != null && start >= 0 && start < origins.size()) {
            if (limit != null && limit > 0) {
                pageContent = origins.subList(start, (start + limit) > origins.size() ?
                        origins.size() : (start + limit));
            }
        }

        PageImpl<OriginRecordRO> page = new PageImpl<>(pageContent, PageRequest.of(0, limit), origins.size());

        return ok(new RestResponse<>(page));
    }

    /**
     * Deletes an origin record.
     *
     * @param id origin record id
     * @return deleted id
     */
    @DELETE
    @Path("/" + RestConstants.PATH_PARAM_ORIGIN + "/{" + RestConstants.DATA_PARAM_ID + "}")
    @Operation(
        description = "Удалить оригинальную запись",
        method = "DELETE",
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = RestResponse.class)), responseCode = "200"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = ErrorResponse.class)), responseCode = "500")
        }
    )
    public Response deleteOrigin(
            @Parameter(description = "ID сущности", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_ID) String id) {

        MeasurementPoint.init(MeasurementContextName.MEASURE_UI_DELETE_BY_ORIGIN);
        MeasurementPoint.start();

        DeleteRequestContext ctx = DeleteRequestContext.builder().originKey(id).inactivateOrigin(true).build();

        try {
            DeleteRecordDTO deleteResult = dataRecordsService.deleteRecord(ctx);
            String deletedKey = getDeletedKey(deleteResult);
            if (StringUtils.isBlank(deletedKey)) {
                final String message = "Origin delete failed (no key received) for ID [{}]!";
                LOGGER.warn(message, id);
                throw new DataProcessingException(message, DataRestExceptionIds.EX_DATA_ORIGIN_DELETE_FAILED, id);
            }

            UpdateResponse response = new UpdateResponse(true, deletedKey);
            if (CollectionUtils.isNotEmpty(deleteResult.getErrors())) {
                response.setErrors(deleteResult.getErrors().stream()
                        .map(ErrorInfoToRestErrorInfoConverter::convert)
                        .collect(Collectors.toList()));
            }
            return Response.ok(response).build();
        } finally {
            MeasurementPoint.stop();
        }
    }

    /**
     * Deletes an origin record.
     *
     * @param id origin record id
     * @return deleted id
     */
    @DELETE
    @Path("/"
            + RestConstants.PATH_PARAM_VERSION + "/{"
            + RestConstants.DATA_PARAM_ID + "}/{"
            + RestConstants.DATA_PARAM_TIMESTAMPS + ":.*}")
    @Operation(
        description = "Пометить версию, как удаленную",
        method = "DELETE",
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = UpdateResponse.class)), responseCode = "200"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = ErrorResponse.class)), responseCode = "500")
        }
    )
    public Response deleteVersion(
            @Parameter(description = "ID сущности", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_ID) String id,
            @Parameter(description = "Значения границ интервалов. Всегда 2 элемента", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_TIMESTAMPS) List<PathSegment> timestamps) {

        MeasurementPoint.init(MeasurementContextName.MEASURE_UI_DELETE_BY_PERIOD);
        MeasurementPoint.start();

        Date validFrom = RestUtils.extractStart(timestamps);
        Date validTo = RestUtils.extractEnd(timestamps);

        DeleteRequestContext ctx = DeleteRequestContext.builder()
                .etalonKey(id)
                .validFrom(validFrom)
                .validTo(validTo)
                .inactivatePeriod(true)
                .build();

        try {
            // DeleteRecordDTO deleteResult = dataRecordsService.deleteRecord(ctx);
            Pipeline p = Pipeline.start(pipelineService.start(RecordDeleteStartExecutor.SEGMENT_ID))
                    .with(pipelineService.point(RecordDeleteAccessExecutor.SEGMENT_ID))
                    .with(pipelineService.point(RecordDeletePeriodCheckExecutor.SEGMENT_ID))
                    .with(pipelineService.point(RecordDeleteDataConsistencyExecutor.SEGMENT_ID))
                    .with(pipelineService.point(RecordDeleteIndexingExecutor.SEGMENT_ID))
                    .with(pipelineService.point(RecordDeletePersistenceExecutor.SEGMENT_ID))
                    .end(pipelineService.finish(RecordDeleteFinishExecutor.SEGMENT_ID));

            DeleteRecordDTO deleteResult = executionService.execute(p, ctx);

            String deletedKey = getDeletedKey(deleteResult);
            if (StringUtils.isBlank(deletedKey)) {
                final String message = "Inactive version PUT failed (no key received) for ID [{}]!";
                LOGGER.warn(message, id);
                throw new DataProcessingException(message, DataRestExceptionIds.EX_DATA_ORIGIN_DEACTIVATION_FAILED, id);
            }
            UpdateResponse response = new UpdateResponse(true, deletedKey);
            if (CollectionUtils.isNotEmpty(deleteResult.getErrors())) {
                response.setErrors(deleteResult.getErrors().stream()
                        .map(ErrorInfoToRestErrorInfoConverter::convert)
                        .collect(Collectors.toList()));
            }
            return Response.ok(response).build();
        } finally {
            MeasurementPoint.stop();
        }
    }


    /**
     * Gets the time line for an etalon.
     *
     * @param etalonId the etalon ID
     * @return response
     */
    @GET
    @Path("/" + RestConstants.PATH_PARAM_TIMELINE + "/{" + RestConstants.DATA_PARAM_ID + "}")
    @Operation(
        description = "Запросить таймлайн. Запросить данные изменения эталона по времени.",
        method = "GET",
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = TimelineRO.class)), responseCode = "200"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = ErrorResponse.class)), responseCode = "500")
        }
    )
    public Response recordsTimeline(
            @Parameter(description = "ID", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_ID) String etalonId,
            @Parameter(description = "Включать неактивные версии в эталон или нет (true/false)", in = ParameterIn.QUERY) @QueryParam(RestConstants.DATA_PARAM_INCLUDE_INACTIVE) String includeInactiveAsString,
            @Parameter(description = "Включать неподтвержденные версии в эталон или нет (true/false)", in = ParameterIn.QUERY) @QueryParam(RestConstants.DATA_PARAM_INCLUDE_DRAFTS) String includeDraftsAsString) {

        Timeline<OriginRecord> timeline = dataRecordsService.loadTimeline(GetRecordTimelineRequestContext.builder()
                .etalonKey(etalonId)
                .fetchData(false)
                .build());

        return Response
                .ok(new RestResponse<>(TimelineToTimelineROConverter.convert(timeline)))
                .build();
    }

    /**
     * Gets the etalon id for an external id.
     *
     * @param externalId external ID
     * @param sourceSystem source system
     * @param entityName entity name
     * @return response
     */
    @GET
    @Path("/" + RestConstants.PATH_PARAM_KEYS + "/" + RestConstants.PATH_PARAM_EXTERNAL
            + "/{" + RestConstants.DATA_PARAM_EXT_ID + "}"
            + "/{" + RestConstants.DATA_PARAM_SOURCE_SYSTEM + "}"
            + "/{" + RestConstants.DATA_PARAM_NAME + "}")
    @Operation(
        description = "Запросить ключи по внешнему ID записи. Запросить ключи по внешнему ID записи.",
        method = "GET",
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = RestResponse.class)), responseCode = "200"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = ErrorResponse.class)), responseCode = "500")
        }
    )
    public Response fetchKeysByExternalId(
            @Parameter(description = "Внешний ключ записи", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_EXT_ID) String externalId,
            @Parameter(description = "Система источник записи", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_SOURCE_SYSTEM) String sourceSystem,
            @Parameter(description = "Имя справочника/реестра", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_NAME) String entityName) {

        GetRequestContext ctx = GetRequestContext.builder()
                .externalId(externalId)
                .sourceSystem(sourceSystem)
                .entityName(entityName)
                .build();

        RecordKeys keys = dataRecordsService.identify(ctx);
        return Response
                .ok(new RestResponse<>(RecordKeysConverter.to(keys)))
                .build();
    }

    /**
     * Gets keys for an etalon id.
     *
     * @param id etalon ID
     * @return response
     */
    @GET
    @Path("/" + RestConstants.PATH_PARAM_KEYS + "/" + RestConstants.PATH_PARAM_ETALON + "/{" + RestConstants.DATA_PARAM_ID + "}")
    @Operation(
        description = "Запросить ключи по эталонному ID записи.",
        method = "GET",
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = RestResponse.class)), responseCode = "200"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = ErrorResponse.class)), responseCode = "500")
        }
    )
    public Response fetchKeysByEtalonId(
            @Parameter(description = "Эталонный ключ записи", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_ID) String id) {

        GetRequestContext ctx = GetRequestContext.builder()
                .etalonKey(id)
                .build();

        RecordKeys keys = dataRecordsService.identify(ctx);
        return Response
                .ok(new RestResponse<>(RecordKeysConverter.to(keys)))
                .build();
    }

    /**
     * Gets the keys for an origin id.
     *
     * @param originId origin ID
     * @return response
     */
    @GET
    @Path("/" + RestConstants.PATH_PARAM_KEYS + "/" + RestConstants.PATH_PARAM_ORIGIN + "/{" + RestConstants.DATA_PARAM_ID + "}")
    @Operation(
        description = "Запросить ключи по оригинальному ID записи.",
        method = "GET",
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = RestResponse.class)), responseCode = "200"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = ErrorResponse.class)), responseCode = "500")
        }
    )
    public Response fetchKeysByOriginId(
            @Parameter(description = "Оригинальный ключ записи", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_ID) String originId) {

        GetRequestContext ctx = GetRequestContext.builder()
                .originKey(originId)
                .build();

        RecordKeys keys = dataRecordsService.identify(ctx);
        return Response
                .ok(new RestResponse<>(RecordKeysConverter.to(keys)))
                .build();
    }

    /**
     * Gets BLOB data associated with an attribute of a golden record.
     *
     * @param blobId LOB object id.
     * @return byte stream
     */
    @GET
    @Path("/" + RestConstants.PATH_PARAM_BLOB + "/{" + RestConstants.DATA_PARAM_ID + "}")
    @Operation(
        description = "Получить двоичные данные записи по ID и имени аттрибута.",
        method = "GET",
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = StreamingOutput.class)), responseCode = "200"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = ErrorResponse.class)), responseCode = "500")
        }
    )
    public Response fetchBlobData(
            @Parameter(description = "ID записи", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_ID) String blobId) {

        FetchLargeObjectContext ctx = FetchLargeObjectContext.builder()
                .binary(true)
                .largeObjectId(blobId)
                .build();

        final LargeObjectResult result = largeObjectsService.fetchLargeObject(ctx);
        final String encodedFilename = StringUtils.isBlank(result.getFileName())
                ? StringUtils.EMPTY
                : FileUtils.urlEncode(result.getFileName());

        return Response.ok(LargeObjectUtils.createStreamingOutputForLargeObject(result))
                .encoding(StandardCharsets.UTF_8.name())
                .header("Content-Disposition", "attachment; filename="
                        + encodedFilename
                        + "; filename*=UTF-8''"
                        + encodedFilename)
                .header("Content-Type", StringUtils.isEmpty(result.getMimeType()) ? MediaType.APPLICATION_OCTET_STREAM_TYPE : result.getMimeType())
                .build();
    }

    /**
     * Gets CLOB data associated with an attribute of a golden record.
     *
     * @param clobId LOB object id.
     * @return byte stream
     */
    @GET
    @Path("/" + RestConstants.PATH_PARAM_CLOB + "/{" + RestConstants.DATA_PARAM_ID + "}")
    @Produces("text/plain")
    @Operation(
        description = "Получить большие символьные данные записи по ID и имени аттрибута.",
        method = "GET",
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = StreamingOutput.class)), responseCode = "200"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = ErrorResponse.class)), responseCode = "500")
        }
    )
    public Response fetchClobData(
            @Parameter(description = "ID записи", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_ID) String clobId) {

        FetchLargeObjectContext ctx = FetchLargeObjectContext.builder()
                .binary(false)
                .largeObjectId(clobId)
                .build();

        final LargeObjectResult result = largeObjectsService.fetchLargeObject(ctx);
        final String encodedFilename = StringUtils.isBlank(result.getFileName())
                ? StringUtils.EMPTY
                : FileUtils.urlEncode(result.getFileName());

        return Response.ok(LargeObjectUtils.createStreamingOutputForLargeObject(result))
                .encoding(StandardCharsets.UTF_8.name())
                .header("Content-Disposition", "attachment; filename="
                        + encodedFilename
                        + "; filename*=UTF-8''"
                        + encodedFilename)
                .header("Content-Type", StringUtils.isEmpty(result.getMimeType()) ? MediaType.TEXT_PLAIN_TYPE : result.getMimeType())
                .build();
    }

    /**
     * Saves binary large object.
     *
     * @param blobId golden record id
     * @param attr attribute
     * @param attachment attachment object
     * @return ok/nok
     */
    @POST
    @Consumes(MediaType.MULTIPART_FORM_DATA)
    @Path("/" + RestConstants.PATH_PARAM_BLOB + "/{" + RestConstants.DATA_PARAM_ATTR + "}" + "{p:/?}{" + RestConstants.DATA_PARAM_ID + ": (([a-zA-Z0-9\\-]{36})?)}")
    @Operation(
        description = "Загрузить двоичные данные записи для ID и имени аттрибута.",
        method = "POST",
        requestBody = @RequestBody(
                content = @Content(
                    mediaType = MediaType.MULTIPART_FORM_DATA,
                    schema = @Schema(implementation = Attachment.class))),
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = RestResponse.class)), responseCode = "200"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = ErrorResponse.class)), responseCode = "500")
        }
    )
    public Response uploadBlobData(
            @Parameter(description = "Имя аттрибута", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_ATTR) String attr,
            @Parameter(description = "ID объекта", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_ID) String blobId,
            @Multipart(required = true, value = RestConstants.DATA_PARAM_FILE) Attachment attachment) {

        UpsertLargeObjectContext ctx = UpsertLargeObjectContext.builder()
                .tags("attribute:" + StringUtils.trim(attr))
                .largeObjectId(blobId)
                .binary(true)
                .input(attachment.getObject(InputStream.class))
                .filename(attachment.getContentDisposition().getParameter(RestConstants.DATA_PARAM_FILENAME))
                .mimeType(attachment.getContentType().toString())
                .acceptance(LargeObjectAcceptance.PENDING)
                .build();

        LargeObjectResult result = largeObjectsService.saveLargeObject(ctx);
        return Response.ok(new RestResponse<>(LargeObjectToRestLargeObjectConverter.convert(result))).build();
    }

    /**
     * Saves character large object.
     *
     * @param clobId golden record id
     * @param attr attribute
     * @param attachment attachment object
     * @return ok/nok
     */
    @POST
    @Consumes(MediaType.MULTIPART_FORM_DATA)
    @Path("/" + RestConstants.PATH_PARAM_CLOB + "/{" + RestConstants.DATA_PARAM_ATTR + "}" + "{p:/?}{" + RestConstants.DATA_PARAM_ID + ": (([a-zA-Z0-9\\-]{36})?)}")
    @Operation(
        description = "Загрузить символьные данные записи для ID и имени аттрибута.",
        method = "POST",
        requestBody = @RequestBody(
                content = @Content(
                    mediaType = MediaType.MULTIPART_FORM_DATA,
                    schema = @Schema(implementation = Attachment.class))),
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = RestResponse.class)), responseCode = "200"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = ErrorResponse.class)), responseCode = "500")
        }
    )
    public Response uploadClobData(
            @Parameter(description = "Имя аттрибута", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_ATTR) String attr,
            @Parameter(description = "ID объекта", in = ParameterIn.PATH)  @PathParam(RestConstants.DATA_PARAM_ID) String clobId,
            @Multipart(required = true, value = RestConstants.DATA_PARAM_FILE) Attachment attachment) {

        if (!"text".equals(attachment.getContentType().getType())) {
            throw new DataProcessingException("The media type [{}] is not allowed for character objects.",
                    DataRestExceptionIds.EX_DATA_INVALID_CLOB_OBJECT, attachment.getContentType().toString());
        }

        UpsertLargeObjectContext ctx = UpsertLargeObjectContext.builder()
                .tags("attribute:" + StringUtils.trim(attr))
                .largeObjectId(clobId)
                .binary(false)
                .input(attachment.getObject(InputStream.class))
                .filename(attachment.getContentDisposition().getParameter(RestConstants.DATA_PARAM_FILENAME))
                .mimeType(attachment.getContentType().toString())
                .acceptance(LargeObjectAcceptance.PENDING)
                .build();

        LargeObjectResult result = largeObjectsService.saveLargeObject(ctx);
        return Response.ok(new RestResponse<>(LargeObjectToRestLargeObjectConverter.convert(result))).build();
    }

    /**
     * Deletes golden blob data.
     *
     * @param blobId the id
     * @param attr the attribute
     * @return ok/nok
     */
    @DELETE
    @Path("/" + RestConstants.PATH_PARAM_BLOB + "/{" + RestConstants.DATA_PARAM_ID + "}")
    @Operation(
        description = "Удалить двоичные данные записи для ID реестра и имени атрибута.",
        method = "DELETE",
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = RestResponse.class)), responseCode = "200"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = ErrorResponse.class)), responseCode = "500")
        }
    )
    public Response deleteBlobData(
            @Parameter(description = "ID сущности", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_ID) String blobId,
            @Parameter(description = "Имя аттрибута", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_ATTR) String attr) {

        DeleteLargeObjectContext ctx = DeleteLargeObjectContext.builder()
                .largeObjectId(blobId)
                .tags("attribute:" + StringUtils.trim(attr))
                .binary(true)
                .build();

        UpdateResponse response = new UpdateResponse(largeObjectsService.deleteLargeObject(ctx), blobId);
        return Response.ok(new RestResponse<>(response)).build();
    }

    /**
     * Deletes golden CLOB data.
     *
     * @param clobId the id
     * @param attr the attribute
     * @return ok/nok
     */
    @DELETE
    @Path("/" + RestConstants.PATH_PARAM_CLOB + "/{" + RestConstants.DATA_PARAM_ID + "}")
    @Operation(
        description = "Удалить символьные данные записи для ID реестра и имени атрибута.",
        method = "DELETE",
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = RestResponse.class)), responseCode = "200"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = ErrorResponse.class)), responseCode = "500")
        }
    )
    public Response deleteClobData(
            @Parameter(description = "ID сущности", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_ID) String clobId,
            @Parameter(description = "Имя аттрибута", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_ATTR) String attr) {

        DeleteLargeObjectContext ctx = DeleteLargeObjectContext.builder()
                .largeObjectId(clobId)
                .tags("attribute:" + StringUtils.trim(attr))
                .binary(false)
                .build();

        UpdateResponse response = new UpdateResponse(largeObjectsService.deleteLargeObject(ctx), clobId);
        return Response.ok(new RestResponse<>(response)).build();
    }

    /**
     * Copy record and save in draft state.
     *
     * @param etalonId the source etalon ID to save
     * @return response
     */
    @POST
    @Path("/" + RestConstants.PATH_PARAM_COPY + "/{" + RestConstants.DATA_PARAM_ID + "}")
    @Operation(
        description = "Копировать запись",
        method = "POST",
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = RestResponse.class)), responseCode = "200"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = ErrorResponse.class)), responseCode = "500")
        }
    )
    public Response copyRecord(@Parameter(description = "ID записи", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_ID) String etalonId) {

        MeasurementPoint.init(MeasurementContextName.MEASURE_UI_COPY_RECORD);
        MeasurementPoint.start();
        List<UpsertRecordDTO> result;
        List<EtalonRecordRO> retval;
        try {
            GetRequestContext ctx = GetRequestContext.builder()
                    .etalonKey(etalonId)
                    .build();

            result = dataRecordsService.copyRecord(ctx);
            retval = new ArrayList<>();
            List<ErrorInfo> messages = new ArrayList<>();

            for (UpsertRecordDTO period : result) {
                EtalonRecordRO periodRO = DataRecordEtalonConverter.to(period.getEtalon(), period.getDuplicateIds());
                buildErrors(messages, period);
                periodRO.setEntityType(metaModelService.instance(Descriptors.DATA).isRegister(periodRO.getEntityName())
                        ? RestConstants.REGISTER_ENTITY_TYPE
                        : RestConstants.LOOKUP_ENTITY_TYPE);
                retval.add(periodRO);
            }

            RestResponse<List<EtalonRecordRO>> response = new RestResponse<>(retval);
            response.setErrors(messages);
            response.setSuccess(true);
            return ok(response);
        } finally {
            MeasurementPoint.stop();
        }
    }

    /**
     * Apply draft state to record
     *
     * @param etalonId the etalon ID
     * @return response
     */
    @POST
    @Path("/" + RestConstants.PATH_PARAM_APPLY_DRAFT + "/{"
            + RestConstants.DATA_PARAM_ID + "}")
    @Operation(
        description = "Опубликовать запись",
        method = "POST",
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = RestResponse.class)), responseCode = "200"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = ErrorResponse.class)), responseCode = "500")
        }
    )
    public Response applyDraftRecord(@Parameter(description = "ID записи", in = ParameterIn.PATH) @PathParam(RestConstants.DATA_PARAM_ID) String etalonId) {

        MeasurementPoint.init(MeasurementContextName.MEASURE_UI_COPY_RECORD);
        MeasurementPoint.start();
        try {
            GetRequestContext ctx = GetRequestContext.builder()
                    .etalonKey(etalonId)
                    .build();

            List<EtalonRecordRO> retval = new ArrayList<>();
            List<ErrorInfo> messages = new ArrayList<>();
            boolean success = true;
            try {
                List<UpsertRecordDTO> result = dataRecordsService.applyDraftRecord(ctx);
                for (UpsertRecordDTO period : result) {
                    EtalonRecordRO periodRO;
                    if (period.getEtalon() == null) {
                        periodRO = new EtalonRecordRO();
                        // TODO @Modules
//                        DataRecordEtalonConverter.copyDQErrors(period.getDqErrors(), periodRO.getDqErrors());
                    } else {
                        periodRO = DataRecordEtalonConverter.to(period.getEtalon(), period.getDuplicateIds());
                        buildErrors(messages, period);
                        periodRO.setEntityType(metaModelService.instance(Descriptors.DATA).isRegister(periodRO.getEntityName())
                                ? RestConstants.REGISTER_ENTITY_TYPE
                                : RestConstants.LOOKUP_ENTITY_TYPE);
                    }
                    retval.add(periodRO);
                }
            } catch (/*PublishingValidationException*/ Exception exc) {
                LOGGER.info("Some published records has errors", exc);
                success = Boolean.FALSE;
                // TODO @Modules
//                for (UpsertRequestContext errorCtx : exc.getCtxs()) {
//                    EtalonRecordRO errorPeriodRO;
//                    if (!(errorCtx.getRecord() instanceof EtalonRecord)) {
//                        errorPeriodRO = new EtalonRecordRO();
//                        DataRecordEtalonConverter.copyDQErrors(errorCtx.getDqErrors(), errorPeriodRO.getDqErrors());
//                    } else {
//                        errorPeriodRO = DataRecordEtalonConverter.to((EtalonRecord) errorCtx.getRecord(),
//                                errorCtx.getDqErrors(), null);
//                        errorPeriodRO.setEntityType(metaModelService.isEntity(errorCtx.getEntityName()) ? RestConstants.REGISTER_ENTITY_TYPE : RestConstants.LOOKUP_ENTITY_TYPE);
//                    }
//                    retval.add(errorPeriodRO);
//                }
            }

            RestResponse<List<EtalonRecordRO>> response = new RestResponse<>(retval);
            response.setErrors(messages);
            response.setSuccess(success);
            return ok(response);
        } finally {
            MeasurementPoint.stop();
        }
    }

    //DO NOT REMOVE! This crappy workaround required for Swagger to generate API docs
    private static class EntityRecordRestResponse extends RestResponse<EtalonRecordRO> {
        @Override
        public EtalonRecordRO getContent() {
            return null;
        }
    }

    /**
     * Gets the key, that was actually deleted.
     *
     * @param result delete operation result
     * @return key
     */
    private String getDeletedKey(DeleteRecordDTO result) {
        return result.wasSuccess()
                ? result.getEtalonKey().getId()
                : StringUtils.EMPTY;
    }

    /**
     * Detach origin record from current etalon record.
     *
     * @param originId Origin id, which detach
     */
    @GET
    @Path("/detach-origin/{" + RestConstants.PATH_PARAM_ORIGIN_ID + "}")
    @Operation(
        description = "Отцеляет запись от текущего эталона.",
        method = "GET",
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = RestResponse.class)), responseCode = "200"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = ErrorResponse.class)), responseCode = "500")
        }
    )
    public Response detachOrigin(@Parameter(description = "ID ориджина", in = ParameterIn.PATH) @PathParam(RestConstants.PATH_PARAM_ORIGIN_ID) String originId) {
        // FIXME: PARTITIONING WONT WORK
        final SplitRecordsDTO splitResult = dataRecordsService.detachOrigin(SplitRecordRequestContext.builder()
                .originKey(originId) // <-- this doesn't work
                .build());
        if (CollectionUtils.isEmpty(splitResult.getErrors())) {
            return Response.ok(new RestResponse<>(splitResult.getEtalonId())).build();
        } else {
            RestResponse response = new RestResponse<>(splitResult.getEtalonId(), true);
            response.setErrors(splitResult.getErrors()
                    .stream()
                    .map(ErrorInfoToRestErrorInfoConverter::convert)
                    .collect(Collectors.toList()));
            return Response.ok(response).build();
        }
    }

    @GET
    @Path("/reindex-etalon/{" + RestConstants.PATH_PARAM_ETALON + "}")
    @Operation(
        description = "Произвести реиндекс для конкретной записи.",
        method = "GET",
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = RestResponse.class)), responseCode = "200"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = ErrorResponse.class)), responseCode = "500")
        }
    )
    public Response reindexEtalon(@Parameter(description = "ID записи", in = ParameterIn.PATH) @PathParam(RestConstants.PATH_PARAM_ETALON) String etalonId) {
        GetRequestContext ctx = GetRequestContext.builder()
                .etalonKey(etalonId)
                .build();
        return Response.ok(new RestResponse<>(dataRecordsService.reindexEtalon(ctx))
        ).build();
    }

    @POST
    @Path("/filter-by-criteria/")
    @Operation(
        description = "Фильтрует заданные записи по критерию и возвращает подошедшие",
        method = "POST",
        requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = FilterByCriteriaRequestRO.class)), description = "Фильтр"),
        responses = {
            @ApiResponse(content = @Content(schema = @Schema(implementation = RestResponse.class)), responseCode = "200"),
            @ApiResponse(content = @Content(schema = @Schema(implementation = ErrorResponse.class)), responseCode = "500")
        }
    )
    public Response filterByCriteria(FilterByCriteriaRequestRO filterByCriteriaRequest) {

        MeasurementPoint.init(MeasurementContextName.FILTER_BY_CRITERIA);
        MeasurementPoint.start();
        try {
            final List<String> etalonsWithoutHoles = dataRecordsService.selectCovered(
                    filterByCriteriaRequest.getEtalonIds(),
                    filterByCriteriaRequest.getValidFrom(),
                    filterByCriteriaRequest.getValidTo(),
                    filterByCriteriaRequest.getTimeIntervalIntersectType().equals("FULL")
            );
            return ok(new RestResponse<>(etalonsWithoutHoles));
        } finally {
            MeasurementPoint.stop();
        }
    }

}
